Boost.handlePullRequestCreation   C
last analyzed

Complexity

Conditions 9

Size

Total Lines 24
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
eloc 22
dl 0
loc 24
rs 6.6666
c 0
b 0
f 0
1
import { AppSettings } from '@/lib/AppSettings';
2
import { BoostHistory, BoostHistoryItem, BoostHistoryItemState } from '@/types/BoostHistory';
3
import { CodeBoost } from '@/lib/CodeBoost';
4
import { generateRunId, versionToShortVersion } from '@/lib/helpers';
5
import { Repository } from '@/lib/Repository';
6
import { Tools } from '@/lib/Tools';
7
import { BoostConfiguration } from '@/types/BoostConfiguration';
8
import edge from 'edge.js';
9
import { existsSync, readFileSync } from 'fs';
10
import semver from 'semver';
11
import simpleGit, { SimpleGit, SimpleGitBase } from 'simple-git';
12
import dayjs from 'dayjs';
13
import { Github } from '@/lib/github';
14
import { eventbus } from '@/lib/EventBus';
15
import { GIT_FILE_ADDED_EVENT } from '@/lib/Events';
16
17
export interface BoostScriptHandlerParameters {
18
    /** arguments passed in from the user */
19
    args: any[];
20
    /** the boost instance */
21
    boost: Boost;
22
    /** information about the current boost run */
23
    currentRun: BoostHistoryItem;
24
    /** a simpleGit instance for the repository */
25
    git: SimpleGit;
26
    /** a collection of commonly-used libraries available to the boost scripts */
27
    libs: {
28
        fs: typeof import('fs');
29
        path: typeof import('path');
30
        semver: typeof import('semver');
31
    };
32
    /** the repository instance */
33
    repository: Repository;
34
    /** a collection of utility functions available to the boost scripts */
35
    tools: Tools;
36
}
37
38
export type BoostScriptHandler = (params: BoostScriptHandlerParameters) => any;
39
40
export class Boost {
41
    protected codeBoost!: CodeBoost;
42
    protected repository: Repository | null = null;
43
44
    public config!: BoostConfiguration;
45
    public path!: string;
46
    public id!: string;
47
    public version!: string;
48
    public repositoryLimits!: {
49
        maxRunsPerVersion: number;
50
        minutesBetweenRuns: number;
51
    };
52
    public pullRequest!: {
53
        title: string;
54
        body: string;
55
        branch: string;
56
    };
57
    public scripts!: any[];
58
    public actions!: any[];
59
    public state: Record<string, any> = {};
60
    public changedFiles: string[] = [];
61
    public runId: string;
62
63
    constructor(codeBoost: CodeBoost, boostPath: string) {
64
        this.runId = generateRunId();
65
        this.config = this.loadConfiguration(boostPath);
66
        this.codeBoost = codeBoost;
67
        this.init(boostPath);
68
    }
69
70
    public init(boostPath: string) {
71
        this.path = `${boostPath}/${this.config.version}`;
72
        this.id = this.config.id;
73
        this.version = this.config.version;
74
75
        this.repositoryLimits = {
76
            maxRunsPerVersion: this.config.repository_limits.max_runs_per_version,
77
            minutesBetweenRuns: this.config.repository_limits.minutes_between_runs,
78
        };
79
80
        this.pullRequest = this.loadPullRequest(this.config.pull_request);
81
        this.scripts = this.loadScripts(this.config.scripts.files);
82
    }
83
84
    public get appSettings(): AppSettings {
85
        return this.codeBoost.appSettings;
86
    }
87
88
    public get history(): BoostHistory {
89
        return this.codeBoost.historyManager.for(this.id);
90
    }
91
92
    public log(message) {
93
        this.codeBoost.log(message, [{ boost: this.id, run_id: this.runId, repository: this.repository?.fullRepositoryName() }]);
94
    }
95
96
    public loadConfiguration(boostPath: string) {
97
        const config = require(`${boostPath}/boost.js`).default;
98
99
        return <BoostConfiguration>config;
100
    }
101
102
    public loadPullRequest(pullRequest: Record<string, any>) {
103
        return {
104
            title: pullRequest.title,
105
            body: pullRequest.body,
106
            branch: `${pullRequest.branch}-v${versionToShortVersion(this.version)}`,
107
        };
108
    }
109
110
    public loadScripts(scripts: string[]) {
111
        const result: any[] = [];
112
113
        for (const script of scripts) {
114
            const fn = `${this.path}/${script}`;
115
116
            if (!existsSync(fn)) {
117
                throw new Error(`Boost script not found: ${fn}`);
118
            }
119
120
            result.push(require(fn).handler);
121
        }
122
123
        return result;
124
    }
125
126
    public async run(repository: Repository, args: any[] = []) {
127
        this.repository = repository;
128
129
        this.repository.initGitListeners(this.runId);
130
131
        const listener = eventbus.on(GIT_FILE_ADDED_EVENT, ({ repository, files, runId }) => {
132
            if (runId !== this.runId || this.repository?.fullRepositoryName() !== `${repository.owner}/${repository.name}`) {
133
                return;
134
            }
135
136
            this.changedFiles.push(...files);
137
        });
138
139
        // changes to historyItem properties will be reflected in the saved history automatically
140
        // as createEntry returns a proxy object
141
        const historyItem: BoostHistoryItem = this.codeBoost.historyManager.createEntry({
142
            run_id: this.runId,
143
            boost: this.id,
144
            version: this.version,
145
            repository: repository.fullRepositoryName(),
146
            started_at: new Date().toISOString(),
147
            finished_at: null,
148
            state: BoostHistoryItemState.RUNNING,
149
            pull_request: null,
150
        });
151
152
        if (!this.canRunOnRepository(repository)) {
153
            historyItem.state = BoostHistoryItemState.SKIPPED;
154
            historyItem.finished_at = new Date().toISOString();
155
156
            this.log(`Cannot run on ${repository.fullRepositoryName()}`);
157
            this.log('Done.');
158
            return false;
159
        }
160
161
        const params = await this.createScriptHandlerParameters(args, historyItem);
162
163
        const catchErrors = async (callBack: CallableFunction, args: any = []) => {
164
            try {
165
                return await await callBack(...args);
166
            } catch (e) {
167
                hasError = true;
168
                this.log(e);
169
                return false;
170
            }
171
        };
172
173
        const handleDryRun = async (callback: CallableFunction, dryRunMessage: string) => {
174
            if (!this.appSettings.dry_run) {
175
                return await callback();
176
            }
177
178
            this.log(`[dry run] ${dryRunMessage}`);
179
            return null;
180
        };
181
182
        let hasError = false;
183
184
        if (!this.appSettings.use_pull_requests) {
185
            this.pullRequest.branch = await repository.defaultBranch();
186
        }
187
188
        if (this.appSettings.use_pull_requests) {
189
            await this.updatePullRequestBranchName();
190
            await this.checkoutPullRequestBranch();
191
        }
192
193
        await catchErrors(async () => {
194
            await this.runInitializationScript(params);
195
            await this.runScripts(params);
196
        });
197
198
        if (!hasError && this.changedFiles.length > 0) {
199
            await catchErrors(async () => {
200
                const remote = this.appSettings.use_forks ? 'fork' : 'origin';
201
                await handleDryRun(
202
                    async () => await repository.git.push(remote, this.pullRequest.branch),
203
                    `push branch ${this.pullRequest.branch} to ${remote}`,
204
                );
205
            });
206
207
            await catchErrors(async () => {
208
                handleDryRun(async () => {
209
                    await this.handlePullRequestCreation({ repository, historyItem });
210
                }, 'create pull request');
211
            });
212
        }
213
214
        historyItem.finished_at = new Date().toISOString();
215
        historyItem.state = hasError ? BoostHistoryItemState.FAILED : BoostHistoryItemState.SUCCEEDED;
216
217
        listener.off(GIT_FILE_ADDED_EVENT, () => {});
218
    }
219
220
    public async handlePullRequestCreation({ repository, historyItem }) {
221
        if (!this.appSettings.use_pull_requests) {
222
            return;
223
        }
224
225
        const loadStringOrFile = (value: string) => {
226
            return existsSync(`${this.path}/${value}`) ? readFileSync(`${this.path}/${value}`, 'utf8') : value;
227
        };
228
229
        const title = await edge.renderRaw(loadStringOrFile(this.pullRequest.title), { boost: this, state: () => this.state });
230
        const body = await edge.renderRaw(loadStringOrFile(this.pullRequest.body), { boost: this, state: () => this.state });
231
232
        const defaultBranch = (await this.repository?.defaultBranch()) ?? 'main';
233
234
        const pr: any = await Github.createPullRequest(repository, this.pullRequest.branch, defaultBranch, title.trim(), body.trim());
235
        historyItem.pull_request = pr?.number;
236
237
        if (pr) {
238
            this.log(`created pull request #${pr.number}`);
239
240
            if (this.appSettings.auto_merge_pull_requests) {
241
                await Github.mergePullRequest(repository, pr.number);
242
                this.log(`merged pull request #${pr.number}`);
243
            }
244
        }
245
    }
246
247
    public async createScriptHandlerParameters(args: any[], historyItem: BoostHistoryItem) {
248
        return <BoostScriptHandlerParameters>{
249
            args,
250
            boost: this,
251
            currentRun: Object.freeze(Object.assign({}, historyItem)),
252
            git: this.repository?.git,
253
            libs: {
254
                fs: require('fs'),
255
                path: require('path'),
256
                semver,
257
            },
258
            repository: this.repository,
259
            tools: new Tools(),
260
        };
261
    }
262
263
    public async runInitializationScript(params: BoostScriptHandlerParameters) {
264
        if (existsSync(`${this.path}/init.js`)) {
265
            const initFn = require(`${this.path}/init.js`).handler;
266
            await initFn(params);
267
        }
268
    }
269
270
    public async checkoutPullRequestBranch() {
271
        await this.repository?.checkout(this.pullRequest.branch);
272
    }
273
274
    /**
275
     * update to a unique branch name if the branch already exists
276
     */
277
    public async updatePullRequestBranchName() {
278
        if (!this.repository) {
279
            return false;
280
        }
281
282
        const branches = await this.repository.localBranches();
283
284
        if (branches.all.includes(this.pullRequest.branch)) {
285
            let counter = 1;
286
            let newBranchName = this.pullRequest.branch;
287
288
            while (branches.all.includes(newBranchName)) {
289
                counter++;
290
                newBranchName = `${this.pullRequest.branch}-${counter}`;
291
            }
292
293
            this.pullRequest.branch = newBranchName;
294
        }
295
296
        return true;
297
    }
298
299
    public async runScripts(params: BoostScriptHandlerParameters) {
300
        if (this.config.scripts.parallel) {
301
            await Promise.allSettled(this.scripts.map(script => script(params)));
302
            return;
303
        }
304
305
        for (const script of this.scripts) {
306
            await script(params);
307
        }
308
    }
309
310
    public canRunOnRepository(repo: Repository | string) {
311
        const repoName = typeof repo === 'string' ? repo : repo.fullRepositoryName();
312
313
        const runs = this.history
314
            .filter(run => run.repository === repoName)
315
            .filter(run => run.version === this.version && run.run_id !== this.runId)
316
            .filter(run => run.state !== BoostHistoryItemState.SKIPPED);
317
318
        if (this.repositoryLimits.maxRunsPerVersion <= runs.length) {
319
            return false;
320
        }
321
322
        return !this.isRunTimeRestricted(runs);
323
    }
324
325
    protected isRunTimeRestricted(runs: BoostHistoryItem[]): boolean {
326
        for (const item of runs) {
327
            const runDate = dayjs(item.started_at);
328
329
            if (dayjs().diff(runDate, 'minute') < this.repositoryLimits.minutesBetweenRuns) {
330
                return true;
331
            }
332
        }
333
334
        return false;
335
    }
336
}
337